[C 기술면접] 전처리기 define 매크로 — 우선순위·부작용·do-while(0) 함정
NOTE
질문 10:
#define을 쓸 때 구문(문장)·변수·표현식을 넣을 경우 주의할 점은? (우선순위,do { } while(0)등) 핵심 한 문장: 매크로는 “값/타입을 가진 함수”가 아니라 컴파일 전에 일어나는 단순 “텍스트 치환”이다. 그래서 연산자 우선순위, 인자 다중 평가(부작용), 문장 경계(세미콜론·if-else), 이름 충돌(스코프)이 모두 문제가 된다. 전개 결과를 머릿속(또는gcc -E)으로 항상 확인한다.
1. 매크로의 본질
- 전처리기가 토큰 단위로 치환한다. 타입 검사 없음, 스코프 없음, 디버거에서 보이지 않음(이름이 사라짐).
- 인자는 평가된 값이 아니라 표현식 텍스트가 그대로 들어간다 → 인자가 여러 번 나오면 여러 번 평가된다.
- 전개를 확인하는 습관:
gcc -E file.c | less(또는-save-temps).
2. 체크리스트 — 매크로를 쓸 때 주의할 점
| # | 주의점 | 증상 | 해결 |
|---|---|---|---|
| 1 | 연산자 우선순위 — 인자 괄호 | SQUARE(1+2)가 1+2*1+2 = 5 | 매크로 본문에서 각 인자를 (x) 로 감싼다 |
| 2 | 연산자 우선순위 — 전체 괄호 | DOUBLE(3)*2가 3+3*2 = 9 | 전체 표현식을 ( ... ) 로 감싼다 |
| 3 | 인자 다중 평가(부작용) | MAX(i++, j)에서 i가 두 번 증가 | 부작용 있는 인자를 넘기지 않는다 / 함수·static inline 사용 / GNU typeof+문장식 |
| 4 | 여러 문장 + if/else | 두 번째 문장이 if 밖으로 빠지고 else가 짝을 잃음 | do { ... } while (0) |
| 5 | 세미콜론 처리 | 매크로 정의 끝에 ;를 넣으면 호출부 ;와 겹쳐 빈 문장 → if/else 깨짐 | 정의 끝에는 ; 넣지 않는다, 호출하는 쪽이 붙인다 |
| 6 | 줄 연속 \ | \ 뒤에 공백이 있으면 치환 실패/경고 | \는 줄 맨 끝에, 뒤에 공백 금지 |
| 7 | 매크로 안 지역 변수 이름 충돌 | 호출부 변수 tmp와 매크로 내부 tmp가 섀도잉 | 고유한 이름(_tmp_는 예약 영역 주의, tmp__mac 등) 또는 함수로 대체 |
| 8 | return/goto/break가 매크로에 숨음 | 호출부에서 흐름 제어가 안 보임 | 이름에 드러내거나(CHECK_NULL_RETURN) 사용을 최소화 |
| 9 | 타입 안전성 없음 | 서로 다른 타입 비교, 정수 승격 문제 | static inline 함수, enum, const |
| 10 | 인자에 쉼표 | MAX(f(a,b), c)에서 쉼표가 인자 구분자로 해석됨(괄호 안은 OK) | 괄호로 감싸거나 가변 인자(__VA_ARGS__) 활용 |
| 11 | #(문자열화) / ##(토큰 결합) | 인자가 매크로면 전개되기 전에 문자열화/결합됨 | 한 단계 더 거치는 간접 매크로로 우회 |
| 12 | sizeof 배열 크기 매크로 | 포인터에 쓰면 포인터 크기 나옴(decay) | 배열 인자에만 사용, 가능하면 컴파일 타임 검사 추가 |
| 13 | 매크로 이름 충돌/오염 | 짧은 이름(MAX, MIN, ERROR)이 라이브러리와 충돌 | 모듈 접두어, 필요 시 #undef, 헤더에 부작용 큰 매크로 정의 지양 |
| 14 | 헤더 가드 / 재정의 | 같은 매크로 두 번 정의 → 경고/에러 | #ifndef 가드, #undef 후 재정의 |
| 15 | 예약 식별자 | __FOO__, _Foo 정의는 표준상 예약 | 밑줄로 시작하지 않는 이름 사용 |
3. 핵심 사례
3.1 우선순위 — 괄호는 “인자마다, 전체에”
#define SQUARE(x) x * x
SQUARE(1 + 2) /* 1 + 2 * 1 + 2 = 5 (기대 9) */
#define SQUARE_OK(x) ((x) * (x))
SQUARE_OK(1 + 2) /* ((1 + 2) * (1 + 2)) = 9 */
#define DOUBLE(x) (x) + (x) /* 전체 괄호 누락 */
DOUBLE(3) * 2 /* (3) + (3) * 2 = 9 (기대 12) */
#define DOUBLE_OK(x) ((x) + (x))
DOUBLE_OK(3) * 2 /* 12 */
#define IS_ODD(x) x % 2 == 1 /* 우선순위 + 인자 괄호 모두 문제 */
#define IS_ODD_OK(x) (((x) % 2) == 1)-1 % 2는 C99부터-1이라IS_ODD가 음수에서 틀릴 수 있으므로!= 0으로 쓰는 편이 안전하다. ((((x) % 2) != 0))
3.2 부작용 — 인자가 여러 번 평가된다
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int i = 5, j = 3;
int m = MAX(i++, j); /* ((i++) > (j) ? (i++) : (j)) → i++ 가 두 번 실행될 수 있음 → UB 가능 */해결:
/* 1) 가장 좋은 방법: 함수 */
static inline int max_int(int a, int b) { return a > b ? a : b; }
/* 2) GNU 확장: 한 번만 평가 + 타입 유지 */
#define MAX_SAFE(a, b) ({ __typeof__(a) _a = (a); __typeof__(b) _b = (b); _a > _b ? _a : _b; })
/* 3) C11 _Generic 으로 타입별 inline 함수 선택 */- 매크로 인자에
++,--, 함수 호출(부작용·비용),rand(),getc()를 넘기지 않는다.
3.3 do { ... } while (0) — 여러 문장을 문장 하나로
#define FREE_AND_NULL(p) free(p); p = NULL; /* 나쁨 */
if (cond)
FREE_AND_NULL(ptr); /* → if (cond) free(ptr); ptr = NULL;; ptr = NULL 은 항상 실행 */
else /* else 가 짝을 잃어 컴파일 에러 */
other();{ }로만 감싸면:
#define FREE_AND_NULL(p) { free(p); (p) = NULL; }
if (cond) FREE_AND_NULL(ptr); /* → if (cond) { ... } ; ← 뒤의 ';' 가 빈 문장이 되어 else 와 끊김 */
else other();정답:
#define FREE_AND_NULL(p) \
do { \
free(p); \
(p) = NULL; \
} while (0) /* 여기에는 세미콜론을 붙이지 않는다 */
if (cond) FREE_AND_NULL(ptr); /* 호출부의 ';' 가 do-while 의 종결자가 됨 → 안전 */
else other();do-while(0)은 문법적으로 문장 하나이면서 내부에 지역 변수를 선언하고break로 조기 탈출도 할 수 있다. 컴파일러가 조건 검사를 최적화로 제거해 비용이 없다.- 한계: 문장이라 값을 반환할 수 없다. 값이 필요하면
static inline함수, 콤마 연산자 표현식, GNU 문장식({ ... }). - 매크로 안에서
return을 쓰는 방어 매크로도 이 패턴으로:
#define CHECK_NULL_RETURN(p, ret) \
do { if ((p) == NULL) { LOG_ERR("null: %s\n", #p); return (ret); } } while (0)→ 참고: 매크로를 do-while(0)으로 감싸는 이유, 전처리기 문법 총정리
3.4 #, ##, 가변 인자 매크로
#define STR(x) #x /* 문자열화: STR(abc) → "abc" */
#define CAT(a, b) a##b /* 토큰 결합: CAT(x, 1) → x1 */
/* 인자가 다른 매크로면 전개 전에 문자열화되는 문제 → 한 단계 간접 */
#define XSTR(x) STR(x)
#define VER 3
STR(VER) /* "VER" */
XSTR(VER) /* "3" */
/* 가변 인자 로그 매크로 */
#define LOG(fmt, ...) fprintf(stderr, "[%s:%d] " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)
/* ##__VA_ARGS__ 는 GNU 확장: 가변 인자가 없을 때 앞의 쉼표를 제거. C23 에서는 __VA_OPT__ */
LOG("start");
LOG("value=%d", v);- 이름 규칙: 로그 매크로 안에서
#p를 쓰면 인자 텍스트를 문자열로 찍을 수 있어 디버깅에 유용하다(#ptr→"ptr").
3.5 매크로 vs const / enum / inline
| 용도 | 매크로 | 더 나은 대안 |
|---|---|---|
| 상수 | #define MAX_SIZE 100 | enum { MAX_SIZE = 100 }; 또는 static const int (디버거에서 이름이 보이고 타입·스코프 있음) |
| 정수 상수 집합 | 여러 #define | enum |
| 짧은 함수 | #define SQ(x) ... | static inline 함수 (타입 검사, 단일 평가) |
| 조건부 컴파일 | #ifdef | 불가피하게 매크로가 필요한 영역 |
문자열화/결합, __FILE__/__LINE__ | 매크로 필요 | — |
| 헤더 가드 | #ifndef | #pragma once (비표준이나 널리 지원) |
- 매크로가 필요한 경우: 조건부 컴파일, 헤더 가드,
__FILE__/__LINE__삽입, 문자열화/토큰 결합, 제어 흐름(return포함 방어 매크로), 타입 독립 코드(제네릭 컨테이너).
4. 꼬리 질문
| 질문 | 답변 요지 |
|---|---|
| 매크로와 인라인 함수의 차이? | 매크로는 전처리 단계의 텍스트 치환(타입 검사·단일 평가 없음), inline은 컴파일러가 처리하는 함수(타입 안전, 인자 한 번 평가, 디버깅 가능). 대부분 static inline이 낫다. |
#define과 const의 차이? | #define은 전처리 치환(메모리 없음, 타입·스코프 없음), const는 타입과 스코프가 있는 변수(주소 취득 가능, 디버거에서 보임). C에서 배열 크기 상수로는 const가 아닌 enum/#define이 필요한 경우가 있다(C99 VLA 제외). |
do-while(0) 말고 { } 만 쓰면 안 되나? | if (c) M(); else ...에서 {...}; 뒤 세미콜론이 빈 문장이 되어 else가 끊긴다. |
매크로 안에서 break/continue/return을 쓰면? | 호출한 함수/루프의 흐름이 바뀐다. do-while(0) 안의 break는 do-while만 탈출하므로 의도와 다를 수 있다. 의도를 이름에 드러낸다. |
#if와 #ifdef의 차이? | #ifdef X는 정의 여부만, #if X는 값을 평가(미정의 식별자는 0). #if defined(A) && B > 2처럼 조합 가능. |
| 매크로가 디버깅이 어려운 이유? | 전처리 후에는 이름이 사라져 디버거가 못 보고, 컴파일 에러가 전개된 코드 기준으로 나온다. gcc -E로 전개 확인, -g3로 매크로 정보를 디버그 정보에 포함 가능. |
| 다중 포함으로 인한 문제? | 헤더 가드(#ifndef) 필수, 가드 이름은 밑줄로 시작하지 않는다. |
ARRAY_SIZE 매크로의 함정? | #define ARRAY_SIZE(a) (sizeof(a)/sizeof((a)[0]))는 배열에만 유효. 함수 인자로 받은 포인터에 쓰면 sizeof(ptr)/sizeof(*ptr)가 되어 틀린 값. (리눅스는 __must_be_array 검사 추가) |
volatile/static 같은 키워드를 매크로로 감싸도 되나? | 가능은 하지만 가독성을 해친다. 팀 컨벤션이 아니면 지양. |
한 줄 요약
- 인자마다 괄호, 전체에 괄호(우선순위) · 부작용 있는 인자 금지(다중 평가) · 여러 문장은
do { } while (0), 정의 끝 세미콜론 금지 · 가능하면static inline/enum/const로 대체 · 애매하면gcc -E로 전개를 확인한다.